01 - Wprowadzenie do PyTorch i sieci konwolucyjnych
Zaawansowane przetwarzanie obrazów
Politechnika Poznańska, Instytut Robotyki i Inteligencji Maszynowej
Ćwiczenie laboratoryjne 1: Wprowadzenie do PyTorch i sieci konwolucyjnych
Powrót do spisu treści ćwiczeń laboratoryjnych
Wstęp
Na zajęciach wykorzystywać będziemy język programowania Python oraz biblioteki dedykowane trenowaniu i ewaluacji sieci neuronowych.
Deep learning
Deep learning (uczenie głębokie) to klasa algorytmów uczenia maszynowego, która opiera się na wykorzystaniu głębokich sztucznych sieci neuronowych (DNNs). Wyróżnia się tym, że sieci te posiadają wiele warstw ukrytych, co pozwala im na automatyczne uczenie się hierarchicznych reprezentacji cech (ang. feature learning) bezpośrednio z surowych danych. To głębokie odwzorowanie umożliwia rozwiązywanie złożonych problemów, takich jak rozpoznawanie obrazów, przetwarzanie języka naturalnego czy generowanie mowy, często z precyzją przewyższającą metody uczenia maszynowego oparte na płytkich architekturach.
W ostatnich latach deep learning zyskał szczególną popularność dzięki kilku kluczowym zmianom:
- Wzrost wydajności sprzętowej: Rozwój technologii, w tym wykorzystanie GPU do zrównoleglania obliczeń, znacząco przyspieszył proces trenowania modeli.
- Lepsze dane: Dostępność większej liczby wysokiej jakości zbiorów danych, co jest kluczowe dla “głodnych danych” sieci neuronowych.
- Ulepszone algorytmy: Postępy w optymalizacji wag i strukturze sieci umożliwiły trenowanie głębszych modeli.
Te czynniki pozwoliły na szybkie trenowanie sieci o wielu warstwach, które osiągają wyniki przewyższające inne algorytmy w zadaniach takich jak wizja komputerowa czy przetwarzanie języka naturalnego. Obecnie różne warianty sieci neuronowych dominują w tych dziedzinach, ustanawiając nowe standardy skuteczności.
PyTorch
Znając teorię, nic nie stoi na przeszkodzie, żeby sieci neuronowe zaimplementować przy użyciu numpy czy nawet zupełnie “od zera”. Niemniej jednak, jak to zwykle bywa, istnieją biblioteki, które znacząco to zadanie ułatwiają, i z racji bycia aktywnie wspieranymi przez społeczność są preferowanym wyborem.
PyTorch to popularna biblioteka open-source do uczenia maszynowego, szczególnie ceniona za dynamiczne grafy obliczeniowe, które umożliwiają elastyczne i intuicyjne debugowanie. Jest najczęściej wykorzystywaną biblioteką do tworzenia i trenowania sieci neuronowych, oferując szeroki zakres narzędzi i funkcji, które ułatwiają implementację złożonych modeli uczenia głębokiego. PyTorch jest szczególnie popularny wśród badaczy i inżynierów ze względu na swoją prostotę, elastyczność oraz silne wsparcie dla obliczeń na GPU i innych akceleratorach, co pozwala na efektywne trenowanie dużych modeli.
Pierwsza sieć neuronowa
Instalacja bibliotek
Aby zainstalować bibliotekę PyTorch, należy odwiedzić stronę https://pytorch.org/get-started/locally/ i postępować zgodnie z instrukcjami dostosowanymi do systemu operacyjnego oraz preferencji dotyczących wersji CUDA (jeśli korzystasz z GPU). Przykładowa komenda instalacyjna dla systemu Linux bez dedykowanej karty graficznej wygląda następująco:
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu126
Import bibliotek
import torch
import torch.nn as nn
import torchvision
Import danych
Złota zasada, “dane są bardzo przydatne w machine learningu” nadal
obowiązuje, zacznijmy więc od zaopatrzenia się w nie. Na obecnym etapie
skorzystamy ze zbioru CIFAR10
.
Zbiór CIFAR10
składa się z 60 000 kolorowych obrazków o
wymiarach 32x32 piksele, podzielonych na 10 klas, takich jak samochód,
ptak czy koń. Każda klasa zawiera dokładnie 6 000 przykładów, co czyni
go dobrze zbalansowanym zbiorem danych. Jest to popularny benchmark w
uczeniu maszynowym, szczególnie w zadaniach związanych z wizją
komputerową. Dzięki swojej prostocie i dostępności, CIFAR10
jest często wykorzystywany do testowania nowych architektur sieci
neuronowych oraz algorytmów optymalizacji. W torchvision
można go łatwo zaimportować i używać, co czyni go idealnym wyborem na
początkowe eksperymenty.
= torchvision.transforms.ToTensor()
convert_to_tensor
= torchvision.datasets.CIFAR10('CIFAR10', download=True, train=True, transform=convert_to_tensor)
train_cifar10 = torchvision.datasets.CIFAR10('CIFAR10', train=False, transform=convert_to_tensor) test_cifar10
Wszystkie zbiory danych, które mają być wykorzystane podczas treningu
dziedziczą i implementują klasę torch.utils.data.Dataset.
Oznacza to, między innymi, że obsługują indeksowanie za pomocą operatora
[]
oraz sprawdzanie ich rozmiaru poprzez wywołanie funkcji
len(...)
.
Standardowo zbiór danych podczas indeksowania zwraca dwie wartości
typu torch.Tensor
, który jest odpowiednikiem macierzy
numpy
i wielu miejscach ma kompatybilny interfejs. W
przypadku klasyfikacji pierwsza wartość zawiera próbkę danych, a druga
etykietę.
💥 Zadanie 1 💥
Sprawdź następujące podstawowe fakty na temat wczytanych danych ze
zbioru CIFAR10
:
- Ile obrazów znajduje się w zbiorze treningowym a ile w testowym?
- Jakiego rozmiaru (rozdzielczość) są obrazy?
- Czy obrazy są w skali szarości czy kolorowe?
- Czy w rozmiarze (
shape
) obrazu zauważasz coś dziwnego?
💥 Zadanie 2 💥
Znamy podstawowe fakty na temat właściwości wczytanych obrazów – natomiast zawsze warto jest też sprawdzić, jak dane wyglądają w rzeczywistości.
Wykorzytując bibliotekę matplotlib
, utwórz wizualizację,
przedstawiającą kilka(naście) przykładowych zdjęć z datasetu
CIFAR10.
Zainspirować można się tutorialem PyTorch.
Lista etykiet dla zbioru CIFAR10
:
= ['airplane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck'] cifar10_classes
Wczytywanie wsadów danych (batch)
Po utworzeniu całego obiektu klasy implementującej
torch.utils.data.Dataset
mamy już możliwość dostępu do
poszczególnych próbek danych. Nie wystarcza to jednak do przeprowadzenia
treningu. Ze względów takich jak szybkość i stabilność procesu uczenia,
w jednym kroku do sieci podaje się wsad (batch) danych składający się z
większej liczby próbek uczących.
Wczytywanie wielu próbek danych, szczególnie jeśli mają one podlegać pewnym modyfikacjom “w locie” może zajmować wiele czasu - warto więc żeby odbywało się w wielu wątkach lub procesach.
PyTorch oferuje warstwę abstrakcji zajmującą się większością wyżej wymienionych czynności za nas. Utwórzmy obiekty klasy torch.utils.data.DataLoader dla zbioru treningowego, walidacyjnego i testowego:
= torch.utils.data.DataLoader(train_cifar10, batch_size=64, num_workers=2)
train_loader = torch.utils.data.DataLoader(val_cifar10, batch_size=64, num_workers=2)
val_loader = torch.utils.data.DataLoader(test_cifar10, batch_size=64, num_workers=2) test_loader
Definiowanie modelu sieci neuronowej
Głębokie sieci neuronowe składają się z wielu warstw, które
przetwarzają dane wejściowe w celu wydobycia istotnych cech i dokonania
klasyfikacji lub regresji. W PyTorch definiowanie modelu sieci
neuronowej odbywa się poprzez tworzenie klas dziedziczących po
torch.nn.Module
. Wewnątrz tych klas definiujemy warstwy
sieci oraz sposób, w jaki dane przepływają przez te warstwy.
Jeśli chcemy zdefiniować prostą sieć neuronową, w której dany
przepływają sekwencyjnie przez warstwy, możemy skorzystać z klasy
torch.nn.Sequential
, która umożliwia łatwe łączenie warstw
w jeden model:
= 3 * 32 * 32
image_size
= nn.Sequential(
model # Warstwa "spłaszczająca", która odpowiada za rozwinięcie wszystkich wymiarów tensora wejściowego do jednowymiarowego,
# ciągłego wektora. Wymagana ze względu na kolejną warstwę.
nn.Flatten(),
# Warstwa w pełni połączona - musi przyjąć in_features (tutaj: rozmiar obrazu) wartości i zwrócić out_features wartości
=image_size, out_features=512),
nn.Linear(in_features# Warstwa, która warstwą nie jest - aplikuje jedynie funkcję aktywacji ReLU. Argument inplace=True jest optymalizacją - modyfikuje tensor,
# który otrzymuje zamiast tworzyć nowy.
=True),
nn.ReLU(inplace
=512, out_features=256),
nn.Linear(in_features=True),
nn.ReLU(inplace
=256, out_features=128),
nn.Linear(in_features=True),
nn.ReLU(inplace
# Wartwa "końcowa", mająca tyle jednostek ile przewidywanych klas
=128, out_features=len(cifar10_classes)),
nn.Linear(in_features# NIE używamy funkcji aktywacji softmax, która normalizuje
# wyjścia sieci tak, że sumują się one do 1, a wartości poszczególnych jednostek możemy traktować jako prawdopodobieństwa klas.
# Podczas uczenia softmax zastosuje za nas funkcja kosztu "CrossEntropyLoss", jednak należy pamiętać
# że ostatecznie wyjścia sieci nie będą znormalizowane.
)
Sama architektura modelu jest kluczowa, ale nie jest jedynym elementem, który należy precyzyjnie określić.
Oprócz niej szczególnie istotne są również:
- Funkcja kosztu (loss function): Różne funkcje kosztu są odpowiednie dla różnych typów problemów, takich jak klasyfikacja, regresja, segmentacja czy detekcja obiektów. Wybór odpowiedniej funkcji kosztu ma kluczowe znaczenie dla skuteczności modelu.
- Metoda optymalizacji (optimizer): Algorytm optymalizacyjny oraz jego hiperparametry (np. współczynnik uczenia – learning rate) wpływają na szybkość i jakość procesu trenowania modelu.
Poniższy kod stworzy zarówno optimizer (używający algorytmu Stochastic Gradient Descent – patrz wykład) jak i loss function (cross-entropy) odpowiednie dla zadania klasyfikacji.
= 1e-1 # 0.1
learning_rate = torch.optim.SGD(model.parameters(), lr=learning_rate)
optimizer = nn.CrossEntropyLoss() loss_function
Po ustaleniu jakiej metody optymalizacji i funkcji kosztu chcemy używać możemy rozpocząć trening modelu.
Trening modelu
Po przygotowaniu definicji modelu możemy sprawdzić jak wygląda całość architektury wyświetlając go:
print(model)
W “surowym” PyTorch to my jesteśmy odpowiedzialni za całość procesu uczenia, walidacji i treningu.
Rozpocznijmy od podstawowej pętli uczącej:
= torch.device('cuda') # wybierzmy odpowiednie urządzenie 'cpu' lub 'cuda'
device = model.to(device) # przenieśmy nasz model na urządzenie
model
for epoch in range(10): # przejdźmy po naszym zbiorze uczącym 10 razy
= 0.0
running_loss for i, data in enumerate(train_loader):
# wczytajmy wsad (batch) wejściowy: dane i etykiety
= data
inputs, labels
# przenieśmy nasze dane na urządzenie
= inputs.to(device)
inputs = labels.to(device)
labels
# wyzerujmy gradienty parametrów
optimizer.zero_grad()
# propagacja w przód, w tył i optymalizacja
= model(inputs)
outputs = loss_function(outputs, labels)
loss
loss.backward()
optimizer.step()
# drukowanie statystyk
+= loss.item()
running_loss if i % 10 == 9: # drukujmy co dziesiąty batch
print('[%d, %5d] loss: %.3f' % (epoch + 1, i + 1, running_loss / 10))
= 0.0
running_loss
print('Finished Training')
Po przeprowadzonym treningu czas na testowanie naszego modelu. Jego dokładność (accuracy) musimy obliczyć sami. Podczas testowania możemy wyłączyć obliczanie gradientów, co znacznie przyspieszy obliczenia:
= 0
correct = 0
total
with torch.no_grad():
for data in test_loader:
= data
images, labels = images.to(device)
images = labels.to(device)
labels
# wyznaczamy wyjście modelu
= model(images)
outputs # klasę której sieć przypisuje największą wartość uznajemy za wybraną
= torch.max(outputs.data, 1)
_, predicted += labels.size(0)
total += (predicted == labels).sum().item()
correct
print('Accuracy of the network on the test images: %d %%' % (100 * correct / total))
💥 Zadanie 3 💥
Przetestuj działanie powyższego kodu. Sprawdź jak zmienia się dokładność modelu w zależności od liczby epok treningowych. Sprawdź jak zmienia się przebieg treningu w zależności od współczynnika uczenia (learning rate).
Po przeprowadzeniu treningu z domyślnymi parametrami notebooka powinniśmy otrzymać wyniki na poziomie accuracy ~40% na zbiorze testowym.
Wyniki nie są takie złe, ~40% to i tak lepiej niż “losowe strzelanie”, które dla 10 zrównoważonych klas daje w teorii tylko 10% skuteczności.
Najgorszym elementem naszego treningu jest jednak brak kontroli nad przeuczeniem - nie używamy zbioru walidacyjnego.
💥 Zadanie 4 💥
Spróbuj wykorzystać zbiór walidacyjny tak, aby po każdej epoce testować na nim nasz model.
💥 Zadanie 5 💥 Spróbuj zmodyfikować architekturę sieci tak, aby uzyskać lepsze wyniki. Możesz spróbować dodać więcej warstw, zmienić liczbę neuronów w warstwach lub dodać warstwy normalizujące (np. BatchNorm). Sprawdź jak zmienia się dokładność modelu w zależności od wprowadzonych zmian.
💥 Zadanie 6 💥
Spróbuj zmienić metodę optymalizacji na inną (np. Adam, RMSprop) i sprawdź jak zmienia się dokładność modelu w zależności od wybranej metody optymalizacji.
💥 Zadanie 7 💥
Czy uczenie sieci w ten sposób jest przyjemne? Spróbuj rozważyć kilka dodatkowych faktów:
- dokładność to nie jedyna metryka jaką możemy chcieć liczyć,
- często podczas treningu manipuluje się stałą uczenia (learning rate),
- podczas uczenia chcielibyśmy zapisywać jego postęp do oprogramowania rysującego go na miłych dla oka wykresach,
- podczas uczenia powinniśmy zapisywać do pliku model, pod warunkiem że poprawił on się na zbiorze walidacyjnym.
Zastanów się jak będzie wyglądała nasza pętla ucząca po dodaniu powyższych funkcjonalności (które i tak nie są wyczerpującym zbiorem wszystkich możliwych - wystaczy wspomnieć trening na wielu GPU lub maszynach).
Lightning
Powyższy kod, choć prosty, jest dość długi i zawiera wiele szczegółów implementacyjnych, które mogą odwracać uwagę od samej idei trenowania modelu. Na szczęście istnieją narzędzia, które pomagają uprościć ten proces. Jednym z nich jest biblioteka Lightning, która wprowadza wyższy poziom abstrakcji nad PyTorch, pozwalając skupić się na architekturze modelu i odpowiednim dobraniu hiperparametrów, a nie na szczegółach treningu.
Biblioteka Lightning ma za zadanie uczynić pracę z biblioteką PyTorch przyjemniejszą. Przede wszystkim porządkuje ona kod i zdejmuje z użytkownika końcowego konieczność ciągłego rozbudowywania i utrzymywania pętli uczącej.
Aby zainstalować bibliotekę Lightning, można użyć następującej komendy:
pip install lightning
💥 Zadanie 8 💥
Zapoznaj się z dokumentacją biblioteki Lightning, w szczególności z sekcją wprowadzającą. Spróbuj przepisać powyższy kod treningu modelu, wykorzystując Lightning. Efektem powinien być kod, który jest krótszy i bardziej przejrzysty, a jednocześnie zachowuje pełną funkcjonalność oryginalnego kodu. Wykonaj pełen trening z walidacją i testowaniem.
Modele dziedziczące po
torch.nn.Module
W powyższym przykładzie zdefiniowaliśmy model sieci neuronowej za
pomocą torch.nn.Sequential
, co jest wygodne dla prostych,
liniowych architektur. Jednak w przypadku bardziej złożonych modeli,
które wymagają niestandardowego przepływu danych lub mają różne gałęzie,
lepszym podejściem jest stworzenie własnej klasy dziedziczącej po
torch.nn.Module
. W ten sposób możemy zdefiniować zarówno
warstwy sieci, jak i sposób, w jaki dane przepływają przez te warstwy,
implementując metodę forward
. Dodatkowo, możemy łatwo
podglądać wielkość tensorów wychodzących z poszczególnych warstw.
Poniżej znajduje się przykład takiego podejścia:
class SimpleNN(nn.Module):
def __init__(self, input_size=3 * 32 * 32, num_classes=10):
super().__init__()
# Warstwa spłaszczająca
self.flatten = nn.Flatten()
# Jedna współdzielona warstwa aktywacji
self.relu = nn.ReLU(inplace=True)
# Warstwy w pełni połączone
self.fc1 = nn.Linear(input_size, 512)
self.fc2 = nn.Linear(512, 256)
self.fc3 = nn.Linear(256, 128)
self.fc4 = nn.Linear(128, num_classes)
def forward(self, x):
# x ma wymiary: [batch_size, 3, 32, 32]
= self.flatten(x) # [batch_size, 3072]
x
= self.fc1(x) # [batch_size, 512]
x = self.relu(x)
x
= self.fc2(x) # [batch_size, 256]
x = self.relu(x)
x
= self.fc3(x) # [batch_size, 128]
x = self.relu(x)
x
= self.fc4(x) # [batch_size, 10]
x
return x
# Utworzenie instancji modelu
= SimpleNN(input_size=3*32*32, num_classes=10)
model print(model)
Taki sposób definiowania modelu daje nam pełną kontrolę nad
przepływem danych i ułatwia debugowanie, ponieważ możemy w metodzie
forward
dodać wypisywanie kształtów tensorów, warunki
logiczne czy nawet pętle.
💥 Zadanie 9 💥
Przetestuj działanie modelu z wykorzystaniem biblioteki Lightning,
ale tym razem zdefiniuj model jako klasę dziedziczącą po
torch.nn.Module
, tak jak w powyższym przykładzie. Upewnij
się, że cały proces treningu, walidacji i testowania działa
poprawnie.
Konwolucyjne sieci neuronowe
Wady sieci w pełni połączonych dla obrazów
Do tej pory w naszych eksperymentach wykorzystywaliśmy sieci w pełni połączone (ang. Fully Connected Networks lub Dense Networks). W tego typu sieciach każdy neuron w danej warstwie jest połączony z każdym neuronem z warstwy poprzedniej. Oznacza to, że jeśli mamy warstwę o 512 neuronach, a dane wejściowe składają się z 3072 wartości (32×32×3 dla kolorowego obrazu CIFAR10), to pierwsza warstwa będzie miała 512 × 3072 = 1,572,864 parametrów (plus 512 biasów).
Chociaż sieci w pełni połączone mogą modelować skomplikowane zależności, mają kilka istotnych wad, szczególnie w kontekście przetwarzania obrazów:
- Ogromna liczba parametrów: Dla obrazów o większych rozmiarach liczba parametrów rośnie wykładniczo, co prowadzi do problemów z pamięcią i czasem treningu.
- Brak wykorzystania struktury przestrzennej: Sieci FC traktują obraz jako płaski wektor, ignorując informację o sąsiedztwie pikseli i strukturze przestrzennej obrazu.
- Podatność na przeuczenie: Ze względu na dużą liczbę parametrów, modele łatwo się przeuczają, szczególnie przy ograniczonych zbiorach danych.
- Brak niezmienności translacyjnej: Jeśli nauczymy sieć rozpoznawać kota w lewym górnym rogu, nie będzie ona automatycznie rozpoznawać kota w prawym dolnym rogu.
Wprowadzenie do sieci konwolucyjnych
Konwolucyjne sieci neuronowe (ang. Convolutional Neural Networks, CNN) zostały zaprojektowane specjalnie do przetwarzania danych o strukturze siatki, takich jak obrazy. Wykorzystują one operację konwolucji, która pozwala na:
- Lokalne przetwarzanie: Neurony reagują tylko na niewielki fragment obrazu (pole recepcyjne).
- Współdzielenie parametrów: Te same filtry (kernele) są stosowane do całego obrazu, co redukuje liczbę parametrów.
- Niezmienność translacyjna: Sieć nauczona rozpoznawać wzorzec w jednym miejscu obrazu, potrafi go rozpoznać również w innych miejscach.
- Hierarchiczne uczenie cech: Kolejne warstwy konwolucyjne uczą się coraz bardziej złożonych wzorców – od prostych krawędzi po kompletne obiekty.
💥 Zadanie 10 💥
Zaimplementuj prostą konwolucyjną sieć neuronową (CNN) do klasyfikacji obrazów z datasetu CIFAR10.
Architektura sieci:
Zaproponuj architekturę składającą się z następujących elementów:
1. **Pierwsza warstwa konwolucyjna:**
- Dane wejściowe: 3 kanały (RGB)
- Liczba filtrów: 32
- Rozmiar kernela: 3×3
- Padding: 1 (aby zachować wymiary)
- Po konwolucji: funkcja aktywacji ReLU
- Max pooling 2×2, który zmniejsza wymiary obrazu o połowę (32×32 → 16×16)
2. **Druga warstwa konwolucyjna:**
- Dane wejściowe: 32 kanały
- Liczba filtrów: 64
- Rozmiar kernela: 3×3
- Padding: 1
- Po konwolucji: funkcja aktywacji ReLU
- Max pooling 2×2 (16×16 → 8×8)
3. **Trzecia warstwa konwolucyjna (opcjonalna):**
- Dane wejściowe: 64 kanały
- Liczba filtrów: 128
- Rozmiar kernela: 3×3
- Padding: 1
- Po konwolucji: funkcja aktywacji ReLU
- Max pooling 2×2 (8×8 → 4×4)
4. **Warstwy w pełni połączone:**
- Spłaszczenie (Flatten)
- Warstwa ukryta z 256 neuronami + ReLU
- Warstwa wyjściowa z 10 neuronami (liczba klas w CIFAR10)
Zaimplementuj powyższą sieć zgodnie z opisem architektury. Porównaj
jej wyniki z siecią w pełni połączoną z poprzednich zadań. Eksperymentuj
z architekturą: zmień liczbę filtrów, dodaj więcej warstw, wypróbuj
dropout (nn.Dropout
) lub batch normalization
(nn.BatchNorm2d
).
Wskazówki:
- Użyj biblioteki Lightning.
- Pamiętaj o poprawnym obliczeniu rozmiaru wejścia do pierwszej warstwy w pełni połączonej – zależy on od liczby warstw poolingowych i liczby filtrów w ostatniej warstwie konwolucyjnej.
- Możesz sprawdzać wielkość tensora po każdej warstwie, aby upewnić się, że wszystkie warstwy są do siebie dopasowane.
- Spróbuj użyć różnych learning rate dla CNN – często potrzebują mniejszego niż sieci w pełni połączone.